Skip to content

fix(client): organizations.getActiveMember addresses the organisation the caller NAMES, not whichever one the session has active - #16761

Merged
huangyiirene merged 10 commits into
mainfrom
claude/issue-16568-get-active-member-organization-id
Sep 9, 2026
Merged

fix(client): organizations.getActiveMember addresses the organisation the caller NAMES, not whichever one the session has active#16761
huangyiirene merged 10 commits into
mainfrom
claude/issue-16568-get-active-member-organization-id

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16568
Clause-②: yes

The defect

organizations.getActiveMember(organizationId) built GET /organization/get-active-member?organizationId=…. better-auth 1.7.2's handler for that path (plugins/organization/routes/crud-members.mjs) reads session.session.activeOrganizationId and never looks at ctx.query, so the query string was dead on arrival: a client doing a permission check for organisation B while A was active was told about A, at 200, with no diagnostic. The SDK's own JSDoc promised "the calling user's membership row in the given organisation" — a declared capability the runtime did not deliver.

Zone 1's hard precondition, measured BEFORE any implementation

Triage recommended list-members but said in writing it had not verified the query shape. It was driven first: a real AuthManager (better-auth 1.7.2, organization plugin, teams enabled) over a real SqlDriver (better-sqlite3 :memory:), one user owning two organisations with A active, plus a second member seeded into B so the filter has something to exclude. Transcript, trimmed to the ids that matter:

CREATE-A                                    -> 200 id=aSkH…  (member row role=owner)
CREATE-B                                    -> 200 id=YerN…  (member row role=owner)
SET-ACTIVE A                                -> 200

R1  get-active-member?organizationId=A      -> 200 {organizationId:A, id:Gbr…, role:'owner', user:{…}}
R2  get-active-member?organizationId=B      -> 200 {organizationId:A, id:Gbr…, role:'owner', user:{…}}   # SAME ROW
R8  get-active-member  (no active org)      -> 400 NO_ACTIVE_ORGANIZATION                                # the card's control

R3  list-members?organizationId=B&filterField=userId&filterValue=SELF        -> 200 {members:[{organizationId:B,…}], total:1}
R4  list-members?organizationId=A&filterField=userId&filterValue=SELF        -> 200 {members:[{organizationId:A,…}], total:1}
R5  same as R3 plus &limit=1                                                 -> 200 {members:[{organizationId:B,…}], total:1}
R9  R3 again with NO active organisation                                     -> 200 {members:[{organizationId:B,…}], total:1}
R7  list-members?organizationId=FOREIGN&filterField=userId&filterValue=SELF  -> 403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION

RA  list-members?organizationId=B  (unfiltered, B now has 2 members)         -> 200 {members:[OTHER, SELF], total:2}
RB  list-members?organizationId=B&filterField=userId&filterValue=SELF&limit=1-> 200 {members:[SELF],  total:1}
RC  list-members?organizationId=B&filterField=userId&filterValue=OTHER       -> 200 {members:[OTHER], total:1}

ANON get-session                            -> 200 null
ANON list-members                           -> 401 UNAUTHORIZED
ANON get-active-member                      -> 401 UNAUTHORIZED

RA/RB/RC are the discriminating leg: with two rows in B, the self filter returns exactly one and the other-user filter returns the other, so filterField=userId really narrows rather than merely not breaking. R3/R4 are the addressing leg. The precondition holds, so option 2 was implemented; nothing was improvised and the decision inbox was not needed.

The vendor premise was re-confirmed on the same drive: the installed version is exactly better-auth 1.7.2 (pinned by PR #16634), and its getActiveMember handler still reads session state only. The card's premise stands.

What changed

packages/client/src/index.ts, organizations.getActiveMember — the signature and the declared return type are byte-identical; only the addressing moved:

  1. GET /get-session for the caller's own user id (bare { user, session } for a signed-in caller, the literal null for an anonymous one — measured);
  2. GET /organization/list-members?organizationId=…&filterField=userId&filterValue=SELF_USER_ID&limit=1, unwrapping the one-entry page.

list-members rows carry the identical shape — {id, organizationId, userId, role, createdAt, user:{id,name,email,image}} — which is why OrganizationMemberWithUserWire does not move.

The JSDoc is corrected in the same stroke, as triage required. #14314's PR had changed it to say the argument is ignored; that sentence is now false, so it is replaced by what the method does, plus every behaviour an existing caller can observe change.

Does the request-byte change constitute a published behaviour change? Yes — declared, not argued away

Triage asked for this in writing, so here it is, item by item. The request bytes change, and so does the answer:

  • naming a non-active organisation now answers that organisation's row instead of the active one's. This is the defect, and enforcing a declaration the SDK has always made;
  • a non-member of the named organisation is refused 403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION where the old shape produced 400 MEMBER_NOT_FOUND — and about a different organisation at that, since the old shape never asked about the named one. Two published error codes, and the input class that reaches each of them is re-chosen;
  • a caller with no active organisation now gets their row instead of 400 NO_ACTIVE_ORGANIZATION. setActive has stopped being a precondition;
  • an anonymous caller still gets 401 UNAUTHORIZED, thrown by the same session middleware that guarded the old route. Nothing client-side is substituted for the server's refusal;
  • one HTTP request became two.

Clause-②: yes — re-declared from the delivered diff

The dispatch carried a no as triage's reading, marked explicitly as not measured. Re-declared here, and it flips. The machine-read declaration is the standalone line at the top of this body, in the fixed spelling — this heading and the paragraphs under it are the argument, not the declaration.

The mechanical floor is clean: no new exported symbol, no new key on a published payload, no signature change, no type change (check:exported-any-returns is untouched, check:dts-closure and check:type-source-resolution both green). But the floor is not the whole test, and the contract-review rule names this exact case as one that needs judgement rather than a mechanism: "在两个已发布码之间重选输入类". That is precisely what the second bullet above is — the input class that produces each of two published ADR-0112 codes is re-chosen — and the answer to which row an existing caller receives changes with it. Under "claim 拿不准 ⇒ 按 yes" that is a yes twice over.

needs:contract-review is hung on this PR at creation, and on the card, as the double carrier requires.

Reverse verification

The fix was committed first, then the pre-fix packages/client/src/index.ts was restored for one run.

  • on-disk proof, both directions: the anchor `organization/list-members` counted 2 before and 1 after (the surviving one is a pre-existing JSDoc occurrence at line 1358 — the printed "expect 0" label in the ablation script was wrong about that constant, the observation was not), and the blob hash moved ef5fa760… to 7fa9e129…;
  • result: 5 of 7 cases red — ① wrong organisation (expected 'org_alpha' to be 'org_bravo'), ② the request bytes, ④ no-active-organisation, ⑤ the 403 envelope (expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'), ⑥ the anonymous 401 request count. ③ (naming the active organisation) and ⑦ (the guard-the-guard leg that drives the dead route directly) stay green, as predicted — ③ is the one case the old shape got right by coincidence;
  • restore proven, not assumed: git checkout HEAD -- …, then git diff HEAD empty and the on-disk blob hash back to ef5fa760…, byte for byte. The script carried a trap … EXIT INT TERM with absolute paths throughout.

No dist is in the resolution path here: the suite imports ./index relatively, i.e. the source in this checkout, so the ablation could not have been read against a stale build.

Tests

New: packages/client/src/organization-get-active-member-addressing.test.ts, 7 cases. Its fixture is not an approximation — every status, code and row shape in it is a transcript line from the drive above, and it keeps the defect alive on get-active-member (that arm still answers the active organisation whatever the query names), so a regression to the old route fails on the row value rather than on a URL string.

run result
pnpm --filter @objectstack/client test 36 files / 461 tests passed
pnpm --filter @objectstack/client typecheck pass — tsc --noEmit + check:test-typecheck (0 files / 0 errors in the debt ledger)
pnpm --filter @objectstack/plugin-auth test 104 files / 2191 tests passed
pnpm --filter @objectstack/plugin-auth typecheck pass — debt ledger unchanged at 10 files / 94 errors / 23 pinned
pnpm --filter '@objectstack/client-react...' build pass (the dependency closure; also the prerequisite two gates below needed)

Gates

Derived from the delivered diff with node scripts/pm/dispatch-gates.mjs --commands, from a tree actually at origin/main (no STALE TREE banner — origin/main had moved twice during the round and was merged in first), and reconciled:

Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED.

All 59 exit 0, each captured before any pipe. Three needed a second lap and none of the three is a NOT MEASURED in the final record:

  • pnpm check:doc-authoring was genuinely red on this diff: the new ledger note carried #16568 in a runtime string, against the maintainer's ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. The id is stripped; git history keeps the anchor. Green.
  • pnpm --filter @objectstack/spec run check:skill-examples and pnpm check:dual-build-cjs-loads both refused for want of built output (the second by its own exit 3 PREREQUISITE NOT MET). Both green after the client-react closure build — 258 prose examples type-check across 3 surfaces.
  • pnpm check:type-check-debt OOM-ed at --max-old-space-size=4096 and answered exit 3, its own PREREQUISITE-NOT-MET code. Re-run at 8192 (the gate itself runs tsc under a CI-shaped 6144 ceiling, so 4096 could never have held the wrapper): green, 5 ledger entries re-measured, none above its recorded number.

Lint is the full repo-wide union, not a narrowing: eslint . --no-inline-config --format json at 012d430b6347 files, 0 errors, 0 warnings, exit 0.

Declared scope extension: one ledger row outside the dispatched file surface

The dispatch named packages/client/src/index.ts plus a test under packages/client/. This PR also edits one row of packages/plugins/plugin-auth/src/auth-route-ledger.ts, and that is deliberate rather than drift: disposition: 'sdk' means "expressed by the SDK", and after this change no SDK method builds that URL, so leaving the row would ship a false statement in a truth ledger created by this diff. It is rebooked server-only with the rationale the hygiene test demands, client dropped, modelled on the neighbouring organization/add-member row which carries exactly this shape.

The bounded in-place exemption's four conditions, each checked rather than asserted: (i) same defect class as the card — a declared capability the runtime does not deliver; (ii) mechanical, with the target shape already pinned by AuthRouteDisposition and the hygiene case that demands a note on every non-sdk row; (iii) zero holders — scanned per-ref against each open PR's own merge-base, positive control fired; (iv) same gate family, no new validation surface (auth-route-ledger.conformance.test.ts, auth-route-ledger-coverage.test.ts and pnpm check:auth-mount-ledger already read this file, and all three are green).

Nothing published moves with it: the module has zero runtime importers in non-test source, and tsup builds only src/index.ts and src/rate-limit-storage.ts, so it cannot reach dist. Hence one changeset, for @objectstack/client alone.

Serial

packages/client/src/index.ts is the #12104 family's hard-serial hot file. Re-measured at claim time rather than inherited: zero holders across 11 of 11 open PRs, per-ref against each PR's own merge-base, with two positive controls firing (packages/cli/src/commands/validate.ts in #16727, packages/client/package.json in #15334). The same scan found zero holders on auth-route-ledger.ts.

验收备注


Generated by Claude Code

`organizations.getActiveMember(organizationId)` built
`GET /organization/get-active-member?organizationId=...`, and better-auth
1.7.2's handler for that path reads `session.session.activeOrganizationId`
and never looks at `ctx.query`. The query string was dead on arrival: a
permission check for organisation B while A was active answered A's row,
with a 200 and no diagnostic.

The method now asks the question honestly, in two requests: `GET
/get-session` for the caller's own user id, then `GET
/organization/list-members?organizationId=...&filterField=userId&filterValue=<self>&limit=1`,
unwrapping the one-entry page. `list-members` reads `ctx.query.organizationId`
and its rows carry the identical shape, so the signature and the declared
return type are unchanged.

The `get-active-member` ledger row is rebooked `server-only`: no SDK method
builds that URL any more, and `sdk` means "expressed by the SDK".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
check:doc-authoring — a runtime string reaches authors and generated
surfaces, none of whom can resolve `#NNNN`; git history keeps the anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/client, @objectstack/plugin-auth, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/authentication.mdx (via /api/v1/auth/get-session (route, a path literal in AUTH_ROUTE_LEDGER))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f6b7c53db7b65bbfb019750efb4e545470b0c2b7packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1f7d99883de0e9812537dc011d8abd0093652f1f — the merge of head f6c694123d7deaf843623d1ccfcefecac651c6c4 into base f6b7c53db7b65bbfb019750efb4e545470b0c2b7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1f7d99883de0e9812537dc011d8abd0093652f1f && git checkout 1f7d99883de0e9812537dc011d8abd0093652f1f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6b7c53db7b65bbfb019750efb4e545470b0c2b7 f6c694123d7deaf843623d1ccfcefecac651c6c4 && git checkout -B drift-repro f6b7c53db7b65bbfb019750efb4e545470b0c2b7 && git merge --no-ff f6c694123d7deaf843623d1ccfcefecac651c6c4

node scripts/docs-audit/affected-docs.mjs --json f6b7c53db7b65bbfb019750efb4e545470b0c2b7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f6b7c53db7b65bbfb019750efb4e545470b0c2b7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Check Changeset: a PR declaring clause-② yes may not grade a package it
grew `patch`. The maintainer's ruling of 2026-09-04 (decision batch #35)
holds that a change to a published package's public surface takes at
least `minor`; a commit type may raise a bump, never lower it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16761 @ eb75819

Verdict: PASS WITH FINDINGS

Ruling implemented: n/a — no ## Ruling recorded exists on card #16568 or on this PR. The directive the PR implements is the triage seat's recommendation (os-zhuang, comment 5576219184, "分诊席" — a seat, not a maintainer ruling), and the PR implements it exactly: option 2 (list-members + filterField=userId self-filter, signature held), the hard precondition measured before implementation, JSDoc corrected in the same stroke, vendor version re-confirmed. The only maintainer ruling cited anywhere (2026-09-04, batch #35 "WHICH LEVEL") governs the changeset level, not this card.

Everything below was verified independently from refs/review/16761 against origin/main (47f751d5d) and the vendor source at the pinned version; nothing was taken from the PR body.

Verification

  1. Card and thread. client SDK organizations.getActiveMember(organizationId) sends an organizationId the server ignores — it answers the session's ACTIVE organization, whatever id the caller names #16568 (6 comments): triage → claim (edited Clause-②: no → yes in place, with a stated reason) → delivery acceptance → two os-dev-report blocks → CI-green note. The claim comment and the PR body now agree on Clause-②: yes.
  2. Diff vs merge-base 7c12e475e — 4 files, +362/−11: .changeset/client-get-active-member-names-the-organisation.md (A), packages/client/src/index.ts (M, +56/−10), packages/client/src/organization-get-active-member-addressing.test.ts (A, 275), packages/plugins/plugin-auth/src/auth-route-ledger.ts (M, 1 row). Governed paths: no — none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** is touched.
  3. The contract. Signature is byte-identical before/after: getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire>. Wire binding moves from GET /organization/get-active-member?organizationId=… (ledger row now server-only) to GET /get-session + GET /organization/list-members?organizationId=…&filterField=userId&filterValue=<self>&limit=1 (both already ledgered sdk). Server answer when the named org ≠ active org, read from better-auth@1.7.2 plugins/organization/routes/crud-members.mjs (pin confirmed in plugin-auth/package.json and the lockfile): listMembers runs orgSessionMiddleware, resolves organizationId = ctx.query.organizationId || session.activeOrganizationId, then findMemberByOrgId({ userId: session.user.id, organizationId })403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION before any filter is applied. The old getActiveMember handler reads session.session.activeOrganizationId only and never ctx.query — the card's premise stands at this version.
    Security: no authorization widening. The named-org read is authorised by the caller's own membership in the named organisation, not by session state; a non-member cannot read any row. The SDK fixes filterValue to the caller's own id, and even an arbitrary filter would expose only what organizations.listMembers (same route, already sdk) exposes today. Anonymous callers are refused 401 by the same middleware; the SDK substitutes nothing client-side.
  4. Clause-②. PR body line 2 carries the literal Clause-②: yes; the card's governing claim now matches. The yes is correct on its own ground (input classes re-chosen between two published ADR-0112 codes; the row an existing caller receives changes). Ledger conformance: client-url-conformance.test.ts drives every method with a recording fetch, so getActiveMember is pinned to build only ledgered URLs (both hit sdk rows); auth-route-ledger-coverage.test.ts resolves client: names — and no row names organizations.getActiveMember any more, so the method is pinned by URL but no longer by name (see F1). return-type-precision.test.ts:950 pins the return type unchanged.
  5. Changeset. @objectstack/client: minor — correct level per batch [WIP] Add query enhancements and advanced validation features #35 (fix( that moves published behaviour cannot be patch on a clause-② yes; check-changeset-no-major level-axis green). No **BREAKING** banner and no ADR-0087 marker, so the gate is silent; whether one is owed is F3.
  6. Tests. 7 cases, no .skip/.only/.todo. Case ① is the revert-reddening pin (named org_bravo while org_alpha active → asserts organizationId === 'org_bravo', id === 'mem_b_self'); the double keeps the defect alive on get-active-member and ⑦ proves it can serve the wrong row, so ① fails on the value under a revert, not on a URL string. Case ⑤ is the negative control (non-member → 403 YOU_ARE_NOT_A_MEMBER… in the code/httpStatus envelope); ⑥ pins anonymous 401 with two requests on the wire. tsconfig.test.json includes src/**/*, so the new file is under check:test-typecheck (debt ledger 0/0). Note the negative control is against the fixture's model of the vendor gate, not the vendor; the server-side authorisation is pinned here by source reading (item 3), not by an in-repo integration test — acceptable, since packages/client has no plugin-auth edge.
  7. CI on eb75819: 39 check runs — 36 success, 3 skipped, 0 failure, 0 in progress. mergeable_state: clean. Head is 21 commits behind origin/main (5 ahead); none of the 21 touch packages/client/src/index.ts, the ledger, or the new test, and a dry merge-tree reports 0 conflicts.

Findings

F1 — ledger rows left incomplete by the PR's own standard (low, same file already in the diff). The PR rebooks the get-active-member row because "a truth ledger must not ship a false statement". By the same standard two rows are now incomplete: GET /api/v1/auth/get-session carries note: 'auth.me and auth.refreshToken both target it' while organizations.getActiveMember now targets it too, and GET /api/v1/auth/organization/list-members names only organizations.listMembers while getActiveMember now builds it (the invite-member row is the precedent for exactly this, with a note). No gate pins it (hence CI green), which is why it is a finding rather than a red. Expectation: extend both notes so the ledger lists every SDK method that builds each URL; that also restores a by-name anchor for getActiveMember, which item 4 shows is otherwise pinned by URL only.

F2 — empty organizationId silently answers the ACTIVE organisation (low). listMembers resolves ctx.query.organizationId || session.activeOrganizationId, so getActiveMember('') returns the active org's row at 200 — the card's "wrong-but-plausible, silently" class, surviving on one input while the JSDoc now says "the GIVEN organisation". Same behaviour as before the PR, so not a regression. Expectation: refuse a falsy id client-side with a loud error (the method already throws loudly for the empty-page case), or document the fallback in the JSDoc; one pinned case either way.

F3 — breaking-ness carrier: the changeset prescribes a migration for an existing caller class but declares no **BREAKING** and no ADR-0087 disposition (medium; maintainer's call). The changeset says, in its own words, "Callers that relied on passing an arbitrary id to read the ACTIVE organisation's row should pass the active organisation's id" — a FROM → TO prescription for callers who followed the JSDoc as it stands on main today (#14314's "the argument is ignored"). Under the launch-window convention the level cannot carry breaking-ness; the banner + ADR-0087 disposition are the only carriers, and check-adr-0087-registration is by design silent unless the author declares. Against that, AGENTS.md rule 3 defines a breaking changeset as one that "removes or renames anything an author can write", and this removes nothing: signature, type and export are unchanged, and the change restores the contract the method was published with. Both readings are defensible; this seat does not manufacture a ruling. Expectation: the maintainer decides. If breaking: add **BREAKING** with explicit FROM → TO lines (three inputs move: non-active org → that org's row; non-member → 403 YOU_ARE_NOT_A_MEMBER… where it was 400 MEMBER_NOT_FOUND; no active org → success where it was 400 NO_ACTIVE_ORGANIZATION) plus one ADR-0087 marker (the gate prints the category set). If not: no edit, and the ruling on the card closes the question for the next PR of this shape.

F4 — informational. The get-session step types its body inline as { user?: { id?: string } } | null rather than reusing auth.me, which declares the wrong SessionResponse envelope (#16760, filed by this PR). Correct choice given #16760 is open; when #16760 lands, this call should collapse onto auth.me.

Landing note

Draft, needs:contract-review on both carriers, Clause-②: yes, and F3 is a declaration decision the maintainer owns — this is a maintainer-only merge. Nothing here is a defect in the code: the addressing is correct, the authorisation is by membership, and the tests would redden on a revert.


Generated by Claude Code

This was referenced Sep 8, 2026
This was referenced Sep 8, 2026
better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`
on `list-members`, so an empty string fell through to session state and came
back 200 carrying the ACTIVE organisation's row — the same silent substitution
this method was fixed to stop making, surviving on one argument while the
JSDoc says "the GIVEN organisation".

The SDK now refuses it before the wire, in the shape `environment(id)` already
uses. The pinned case asserts nothing reaches the wire at all, and drives
`list-members` with an empty id through the same double to show the fallback
the refusal prevents is real in the fixture, not assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
… each URL

`get-active-member` was rebooked `server-only` because a truth ledger must not
ship a false statement; by the same standard two rows were left incomplete.
`get-session` named only `auth.me` and `auth.refreshToken`, and `list-members`
named only `organizations.listMembers`, while `organizations.getActiveMember`
now builds both. The `invite-member` row is the precedent for exactly this.

Also restores a by-name anchor for the method: after the rebooking it was
pinned by URL through `client-url-conformance.test.ts` but by no `client:` or
`note:` string anywhere in the ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5
The changeset now carries the `**BREAKING**` banner, one before/after pair per
moved input, and an ADR-0087 `not-required (no-migration-prescription)`
disposition. The level stays `minor`: under the launch-window convention the
level cannot carry breaking-ness, so the banner and the disposition are the
carriers.

Four inputs move, each stated as the response it drew before and the response
it draws now: an id other than the active organisation; an organisation the
caller is not a member of; any id on a session with no active organisation;
and an empty id, which this round refuses client-side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5

os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Director seat adoption record — summon #20, session_01Tep4AYXZvyBA7jsvne5KZV (os-bill), 2026-09-09T06:59Z. The verdict below is adopted verbatim from an isolated contract-review subagent (explicit model = CONTRACT_REVIEW_TIER). Transcript tier check before adoption: every harness-stamped model field in the subagent transcript reads claude-fable-5-1 (87 stamps, no other value). Head re-read at posting time = 4ebf8692d9, unchanged since the review. ⛔ This seat takes no release action on this carrier (no ready flip, no auto-merge, no enqueue, no label write): the owning seat (domain:engine) adopts this verdict verbatim or discards it, and acts per the state machine.


Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16761 @ 4ebf8692d9c5cfb896c6d4d03c50a6af289b2278

Verdict: PASS WITH FINDINGS

Ruling implemented: 5580367898 (card #16568, os-zhuang, 2026-09-08 — "the caller-class migration is breaking; carry the carriers"), made executable by the consolidated seat's note 5594607180 (marker category no-migration-prescription, before/after phrasing, stop-and-report if the gate refuses). Both are applied to the letter on the moved head; the gate did not refuse, so the note's step 3 never triggered. Everything below was re-derived from refs/pr-review/16761 against origin/main (854639b3), the vendor tarball better-auth@1.7.2 pulled from the npm registry, and the two changeset gates run offline by this seat; nothing was taken from the PR body or any seat comment.

Owed items from the prior review

Increment since eb75819 (PR-authored, merge of main excluded): d3ddfa11, b4dc6258, 4ebf8692 — 4 files, the same four paths, no new path. Verified by line:

owed status evidence
F1 — ledger notes on get-session and list-members name every SDK method that builds each URL done packages/plugins/plugin-auth/src/auth-route-ledger.ts:158 (auth.me, auth.refreshToken and organizations.getActiveMember all target it …), :262 (organizations.listMembers and organizations.getActiveMember both build it — the latter with filterField=userId&filterValue=<the caller>&limit=1 …). Tracker ids absent from both strings (the check:doc-authoring ruling).
F2 — refuse a falsy organizationId client-side with a loud error, one pinned case done packages/client/src/index.ts:3483-3485 if (!organizationId) throw new Error('[ObjectStack] organizations.getActiveMember: organizationId is required'), JSDoc @param/@throws at :3472-3476; pinned by case ⑧ organization-get-active-member-addressing.test.ts:276-301, which asserts urls is [] (nothing on the wire) and drives the fallback the guard prevents through the same double (guard-the-guard).
F3 (ruled) — **BREAKING** banner, FROM→TO per moved input, ADR-0087 disposition, level stays minor done .changeset/client-get-active-member-names-the-organisation.md:2 "@objectstack/client": minor; :7 **BREAKING** — …; :22-25 four before/after bullets (non-active id, non-member, no active org, empty id); :33 <!-- adr-0087: not-required (no-migration-prescription) SDK call-site change, no metadata conversion --> — exactly one marker, parses under the gate's readDisposition regex (scripts/check-adr-0087-registration.mjs:1502), category in the closed set.
Consolidated seat's patch-round claim 5594619517 claims hold file surface = exactly the 4 paths in the diff; the extra index.ts hunks at ~:430, ~:1058, ~:1150 visible in eb75819..head came in via the main merge 7723332f, not this PR — origin/main...head touches only the getActiveMember region :3428-3513.

Gates re-run by this seat, read-only, against the review ref: node scripts/check-adr-0087-registration.mjs --base origin/main --head refs/pr-review/16761exit 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (no-migration-prescription)"); node scripts/check-changeset-no-major.mjs --base origin/main --head refs/pr-review/16761 --event <live PR payload>exit 0 ("no major bump"; "LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/**/src/** it moves is graded patch · carrier IS on this PR · declaration line: Clause-②: yes").

Derived judgments

  1. Published client signature — unchanged. getActiveMember: async (organizationId: string): Promise<OrganizationMemberWithUserWire> (index.ts:3479), byte-identical to main; return-type-precision.test.ts:950 still pins the return type; no export added or removed.
  2. Wire behaviour — now two requests, addressing honest. GET {auth}/get-session (with Origin, the same shape auth.me uses at :3989) → GET {auth}/organization/list-members?organizationId=<enc>&filterField=userId&filterValue=<enc self>&limit=1page.members[0], else a loud throw (:3508-3511). The dead route get-active-member is no longer built by any SDK method (git grep at head: only ledger prose, tests and the changeset name it).
  3. Vendor premise — confirmed at the pinned version from source, not from the PR. packages/plugins/plugin-auth/package.json:40 pins "better-auth": "1.7.2"; in that tarball's dist/plugins/organization/routes/crud-members.mjs: getActiveMember reads session.session.activeOrganizationId only and never ctx.query (400 NO_ACTIVE_ORGANIZATION / 400 MEMBER_NOT_FOUND / 200 row); listMembers resolves ctx.query?.organizationId || session.session.activeOrganizationId, then findMemberByOrgId({ userId: session.user.id, organizationId })FORBIDDEN YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION before any filter; limit schema is z.string().or(z.number()).optional() coerced with Number(...), filterField/filterValue are free strings → the query the SDK sends is accepted as-is. get-session answers ctx.json(null) for an anonymous caller (api/routes/session.mjs:157); orgSessionMiddleware wraps sessionMiddleware (call.mjs:11) → 401 on the second request, server-thrown, nothing client-invented. The runtime mount (packages/runtime/src/domains/auth.ts:138) returns the auth service's Response with its body untouched, so the bare { user, session } / null the SDK types inline is what a deployed server serves.
  4. Security — no authorization widening. Read authorised by the caller's own membership in the named organisation, not by session state; filterValue is fixed to the caller's own id; an arbitrary filter would expose no more than organizations.listMembers (same route, already sdk) does today.
  5. Server side — unchanged. packages/rest untouched; plugin-auth moves only auth-route-ledger.ts (three rows). That module has zero non-test importers at head and is not a tsup entry (tsup.config.ts:40: ['src/index.ts', 'src/rate-limit-storage.ts']), so nothing published moves in @objectstack/plugin-auth and no changeset is owed there. Rebooking get-active-member to server-only ("Deliberately not SDK surface", type doc at ledger :60) is the right word — gap would assert the SDK should call a route that cannot answer the question — and the non-sdk note the conformance test demands (auth-route-ledger.conformance.test.ts:164) is present.
  6. Spec contracts — no packages/spec path; OrganizationMemberWithUserWire / OrganizationMembersPage (index.ts:1265, :1368) unchanged.
  7. Tests — 8 cases, no .skip/.only/.todo, under tsconfig.test.json's src/**/*. ① is the revert-reddening value pin, ⑦/⑧ are guard-the-guard legs, ⑤ pins the ADR-0112 envelope (code, httpStatus), ⑥ pins the anonymous 401 with two URLs on the wire. client-url-conformance.test.ts:389-393 catches a throw after the request, so with its placeholder body the method records both URLs and both match sdk rows.
  8. Clause-②: yes — right. Mechanical floor clean (no new symbol, key, signature or type). But per .claude/skills/pm-dispatch/references/contract-review.md:14-15 "在两个已发布码之间重选输入类" is judgement, and here the input classes reaching 400 MEMBER_NOT_FOUND / 403 YOU_ARE_NOT_A_MEMBER… / 400 NO_ACTIVE_ORGANIZATION / 200 are re-chosen, and the empty-id class now goes to a client throw. PR body line 2 is the literal Clause-②: yes; the governing claim 5594619517 says yes; needs:contract-review is on both carriers (PR labels and card labels read at review time).

Semver / changeset

  • @objectstack/client: minor — correct: clause-② yes + packages/client/src/** moved ⇒ ≥ minor (level-axis, [finding] No gate answers whether a changeset's LEVEL fits the surface — Check Changeset is green on patch and on minor for the same diff #16055); patch was the red at 012d430b, fixed at eb75819. Launch-window rule: major is refused, so the level cannot carry breaking-ness.
  • **BREAKING** banner present (:7), required by ruling 5580367898. The gate's only carriers during the window are the banner and the ADR-0087 disposition — both present.
  • ADR-0087 not-required (no-migration-prescription): on the merits, not only on detector silence — ADR-0087 registers metadata conversions (objectstack migrate meta, spec-changes.json), and this diff converts no metadata; runtime-interface-only is closed (dotted member path, and the change is behavioural), type-surface-only does not apply. findMigrationPrescription returns null on the body (gate exit 0 offline and in CI Check Changeset, the job that hosts both steps, success 03:29Z on this head).
  • No @objectstack/plugin-auth entry — correct (item 5).

Boundary flags

  • Governed paths: none. The four files hit none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** (register in scripts/pm/check-governed-merges.mjs; CI "Governed Surface Queue Guard" success). Lands through the queue after the owning seat clears the carriers; not maintainer-by-hand.
  • Dev's latest os-dev-report is 5579200601 (2026-09-08T04:26Z, on eb75819): open_questions: []; deviations: [FOOTER] — a PR-body attribution-footer duplication, corrected by read-back; no contract effect, accepted. The delivery report 5578965259: open_questions: []; deviations SCOPE (ledger row — accepted, verified in item 5), CHANNEL / RESOURCE / BASE (tooling, no contract bearing — noted), MEASUREMENT SITE (probe not shipped — accepted: this seat read the vendor handlers independently, item 3).
  • No os-dev-report exists for the patch round (d3ddfa11..4ebf8692): the dev was killed by a 429 before the gate union and the report (5597048143, dead-claim recovery; card now pm:queue, no assignee). See F1.
  • Sibling objectui calls better-auth's own organization.getActiveMember (packages/auth/src/createAuthClient.ts:850), not the ObjectStack SDK → no downstream consumer in the sibling moves. content/docs names getActiveMember nowhere; the drift check's one row (permissions/authentication.mdx, via get-session) lists the route as a route only — accurate.
  • Head is 34 behind / 9 ahead of origin/main; none of the 34 touch the PR's four files; git merge-tree --write-tree clean. GitHub reported mergeable_state: unknown at read time (not yet recomputed) — a dry merge says clean.

Findings

F1 — non-blocking (process): the patch-round increment has no os-dev-report. d3ddfa11, b4dc6258, 4ebf8692 were pushed, the PR body was updated, and then the dev died (5597048143). CI on the head is green end-to-end and this seat re-ran both changeset gates offline, so the contract surface is covered; the report is still owed by whoever re-claims (card is pm:queue, unassigned). Expectation: the next dispatcher posts it per the resume shape in 5597048143; no code change.

F2 — non-blocking (wording accuracy in shipped CHANGELOG text): the "before" on the non-member bullet is over-stated. .changeset/…names-the-organisation.md:23 and packages/client/src/index.ts:3456-3457 say a non-member of the named organisation was answered 400 MEMBER_NOT_FOUND before. From the vendor handler (item 3), the old route answered about the active organisation: a caller who was a member there got a 200 with the active row — the silent wrong answer — and 400 MEMBER_NOT_FOUND fired only when the caller also had no row in the active organisation. The PR's own ablation agrees (case ⑤ went red as "expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'", i.e. the old shape resolved, it did not throw). The text originates in the prior review's F3 and the ruling that quoted it, so the dev implemented what was ruled; the "after" (403) is right, and bullet :22 already states the true before-state for every non-active id. Expectation: one-line correction in both places ("Before: a 200 carrying the active organisation's row, or 400 MEMBER_NOT_FOUND when the caller had no row there either") — travels with whatever patch posts the F1 report; not a landing blocker.

F3 — non-blocking (informational): the falsy-id refusal is a plain Error. index.ts:3484 throws without code/httpStatus, unlike the server refusals the method surfaces in the ADR-0112 envelope. This matches the review's own F2 expectation and the pre-existing empty-page throw at :3509-3511; a caller branching on err.code sees undefined for this one input. No action in this PR.

F4 — non-blocking (informational): organizations.getActiveMember is pinned by URL and value, no longer by name. No client: field in the ledger names it (the notes do, but auth-route-ledger-coverage.test.ts:57 resolves client: only). The URL conformance sweep and cases ①–⑧ carry it. Same residual the prior review recorded; no action.

F5 — non-blocking (disclosure to the maintainer): the ADR-0087 exemption rests on before/after phrasing. The ruling asked for "FROM→TO lines"; they are delivered as Before/After observations (:22-25) with one hint ("auth.me() is where that id is readable"), per execution note 5594607180, whose veto window was not exercised. The category is right on substance (no metadata conversion), so this is a disclosure, not a defect.

CI at read time

Head 4ebf8692d9c5cfb896c6d4d03c50a6af289b2278: 37 check runs, 33 latest-per-name: 28 success, 5 skipped, 0 failure, 0 in progress. Skipped (all label-gated or opt-in): Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in). Green include Check Changeset (03:29:15Z — hosts the ADR-0087 and no-major steps), Lint & Repo Gates, all four Type Check · lanes + aggregator, Test Core 6/6 + aggregator, Dogfood Regression Gate 3/3, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, both single-writer/issue-claim guards. PR is draft with needs:contract-review; card carries needs:contract-review, pm:queue, no assignee.

Implemented-by: branch claude/issue-16568-get-active-member-organization-id
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)

{"pr":16761,"head":"4ebf8692d9c5cfb896c6d4d03c50a6af289b2278","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"33 latest-per-name: 28 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in_progress"}


Generated by Claude Code

…he named organisation

The changeset bullet and the `getActiveMember` docblock both said a caller who
was not a member of the NAMED organisation used to draw `400 MEMBER_NOT_FOUND`.
better-auth 1.7.2's `get-active-member` handler reads
`session.session.activeOrganizationId` and never `ctx.query`, so the named
organisation was never consulted at all: such a caller drew a 200 carrying the
ACTIVE organisation's row, and `MEMBER_NOT_FOUND` fired only when the caller
had no row in the active organisation either. The PR's own ablation agrees —
case ⑤ went red as "expected undefined to be 'YOU_ARE_NOT_A_MEMBER…'", i.e.
the old shape resolved rather than throwing.

Both sentences now state that before-state. The `after` (403) was already
right, and the neighbouring bullets already stated it for every other input.

Prose only: the changeset body ships as CHANGELOG text and the docblock is a
comment. No executable line, no test and no behaviour moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3

Copy link
Copy Markdown
Collaborator

Landing note — the standing PASS WITH FINDINGS (5597568086, head 4ebf8692d9) carries to head f6c694123d7deaf843623d1ccfcefecac651c6c4; carriers come off both faces (director seat, summon #18 segment 3, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T09:4xZ)

Delta verified by this seat at CONTRACT_REVIEW_TIER (git diff 4ebf8692d9..f6c694123d): 2 files, +5/−3, text only — the changeset bullet .changeset/client-get-active-member-names-the-organisation.md:23 and the getActiveMember docblock packages/client/src/index.ts:3455-3459. Both now state the true before-state for a non-member of the named organisation: the named organisation was never consulted, the answer was about the active one (a 200 with the active organisation's row, or 400 MEMBER_NOT_FOUND when the caller had no row there either). No executable line, no fixture, no test quotes either sentence (git grep on both fragments → the two corrected sites only). ⇒ Verdict F2 discharged; F1 (the owed os-dev-report) discharged at #16568 5599246164 with the gates re-run on this head (check-changeset-no-major 0 · check-adr-0087-registration 0 not-required (no-migration-prescription) · @objectstack/client test 0, 36 files / 462 tests · typecheck 0 · repo-wide eslint 0). The accept-set findings of the original verdict are unchanged by a text-only delta, so no re-review is owed.

Two deviations the round recorded, both accepted as-is: the commit trailer names the model that actually did the round rather than the one the brief prescribed — correct, a trailer is evidence of who worked; and the PR body's own "about a different organisation at that" sentence carries the same over-statement the changeset had — not shipped text, left alone rather than rewriting an intact body (the changeset and docblock are what ship).

Chain: needs:contract-review off this PR and off #16568 (one write each, read back); ready + auto-merge armed once CI on f6c69412 is green (09:4xZ reading: 16 success / 11 in progress / 3 skipped / 0 failure — queue entry waits for green, per the queue-entry rule). Non-governed code PR; card closes by Fixes #16568 on merge.

Implemented-by: claude/issue-16568-get-active-member-organization-id (tail round under claim 5598822528) · Reviewed-by: 5597568086 (isolated claude-fable-5-1, summon #20) + this seat's delta reading.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 9, 2026 09:34
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Enqueue record — director seat, summon #18 segment 3 (session_017Js5kTpTtxieBjPyScgxJ3, GitHub huangyiirene).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

5 participants